ATOM Documentation

← Back to App

Multi-Tenancy Patterns

Critical patterns for ensuring tenant isolation and preventing cross-tenant data leaks.

---

🔴 CRITICAL IMPORTANCE

**Multi-tenant isolation is the most critical security aspect of ATOM SaaS.**

A single mistake can lead to:

  • 🔴 **Cross-tenant data leaks** (Tenant A sees Tenant B's data)
  • 🔴 **Compliance violations** (GDPR, SOC2, HIPAA)
  • 🔴 **Security breaches** (Unauthorized access)
  • 🔴 **Legal liability** ( Lawsuits, fines)

**Always verify tenant context. Never skip tenant filtering.**

---

Tenant Isolation Layers

ATOM SaaS implements defense-in-depth with 5 isolation layers:

Layer 1: Subdomain Routing

Each tenant gets unique subdomain mapped to tenant_id.

**Implementation:**

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getTenantFromSubdomain } from '@/lib/tenant/tenant-service';

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const subdomain = hostname.split('.')[0];

  // Skip for main domain
  if (subdomain === 'app' || subdomain === 'www') {
    return NextResponse.next();
  }

  // Resolve tenant from subdomain
  const tenant = await getTenantFromSubdomain(subdomain);

  if (!tenant) {
    return NextResponse.json(
      { error: 'Tenant not found' },
      { status: 404 }
    );
  }

  // Add tenant to request headers for downstream use
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('X-Tenant-ID', tenant.id);
  requestHeaders.set('X-Tenant-Name', tenant.name);

  return NextResponse.next({
    request: { headers: requestHeaders }
  });
}

export const config = {
  matcher: ['/api/:path*', '/agents/:path*']
};

**✅ CORRECT:**

// Always extract tenant from request
export async function GET(request: NextRequest) {
  const tenantId = request.headers.get('X-Tenant-ID');
  if (!tenantId) {
    return NextResponse.json(
      { error: 'Tenant context required' },
      { status: 400 }
    );
  }

  // Use tenantId for all operations
  const agents = await db.agents.findMany({
    where: { tenantId }
  });

  return NextResponse.json({ agents });
}

**❌ WRONG:**

// NEVER skip tenant extraction
export async function GET(request: NextRequest) {
  // CROSS-TENANT LEAK!
  const agents = await db.agents.findMany();

  return NextResponse.json({ agents });
}

---

Layer 2: Tenant Context Extraction

Extract and validate tenant context on every request.

**Frontend (API Routes):**

// src/lib/tenant/tenant-service.ts
import { headers } from 'next/headers';
import { Database } from '@/lib/database';

export async function getTenantFromRequest(request?: Request) {
  const headersList = request ? headers() : await headers();
  const tenantId = headersList.get('X-Tenant-ID');

  if (!tenantId) {
    return null;
  }

  const db = new Database();
  const tenant = await db.tenants.findUnique({
    where: { id: tenantId }
  });

  if (!tenant) {
    throw new Error('Tenant not found');
  }

  if (tenant.status !== 'active') {
    throw new Error('Tenant is not active');
  }

  return tenant;
}

**Backend (FastAPI):**

# core/tenant.py
from fastapi import Header, HTTPException
from sqlalchemy.orm import Session

async def get_tenant_from_request(
    x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
    db: Session = Depends(get_db)
) -> Tenant:

    tenant = db.query(Tenant).filter(
        Tenant.id == x_tenant_id,
        Tenant.status == "active"
    ).first()

    if not tenant:
        raise HTTPException(status_code=404, detail="Tenant not found")

    return tenant

# Usage in endpoints
@router.get("/agents")
async def list_agents(
    tenant: Tenant = Depends(get_tenant_from_request),
    db: Session = Depends(get_db)
):
    agents = db.query(AgentRegistry).filter(
        AgentRegistry.tenant_id == tenant.id
    ).all()
    return {"agents": agents}

---

Layer 3: Application-Level Filtering

Every database query MUST filter by tenant_id.

**TypeScript (Prisma):**

// ✅ CORRECT: Always filter by tenantId
const agents = await db.agentRegistry.findMany({
  where: {
    tenantId: tenant.id,
    status: 'active'
  }
});

// ❌ WRONG: No tenant filter
const agents = await db.agentRegistry.findMany({
  where: {
    status: 'active'
  }
});

**Python (SQLAlchemy):**

# ✅ CORRECT: Always filter by tenant_id
def get_agents(tenant_id: str, db: Session):
    return db.query(AgentRegistry).filter(
        AgentRegistry.tenant_id == tenant_id
    ).all()

# ❌ WRONG: No tenant filter
def get_agents(db: Session):
    return db.query(AgentRegistry).all()

**❌ NEVER use raw SQL without tenant filter:**

# DANGEROUS: Cross-tenant leak possible
query = "SELECT * FROM agents WHERE status = 'active'"
results = db.execute(query)

# SAFE: Always include tenant_id
query = """
    SELECT * FROM agents
    WHERE tenant_id = :tenant_id
    AND status = 'active'
"""
results = db.execute(query, {"tenant_id": tenant_id})

---

Layer 4: Row-Level Security (RLS)

PostgreSQL RLS provides database-level tenant isolation.

**Migration:**

# alembic/versions/xxx_add_rls.py
from alembic import op
import sqlalchemy as sa

def upgrade():
    # Enable RLS on tables
    op.execute("ALTER TABLE agents ENABLE ROW LEVEL SECURITY")
    op.execute("ALTER TABLE sessions ENABLE ROW LEVEL SECURITY")
    op.execute("ALTER TABLE episodes ENABLE ROW LEVEL SECURITY")

    # Create tenant isolation policy
    op.execute("""
        CREATE POLICY tenant_isolation ON agents
        FOR ALL
        USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
    """)

    op.execute("""
        CREATE POLICY tenant_isolation ON sessions
        FOR ALL
        USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
    """)

    op.execute("""
        CREATE POLICY tenant_isolation ON episodes
        FOR ALL
        USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
    """)

**Setting Tenant Context:**

# core/database.py
from contextvars import ContextVar

_tenant_id_ctx: ContextVar[str | None] = ContextVar('_tenant_id_ctx', default=None)

def set_tenant_context(tenant_id: str):
    """Set tenant context for current request."""
    _tenant_id_ctx.set(tenant_id)

    # Set PostgreSQL RLS variable
    db.execute(
        "SET LOCAL app.current_tenant_id = '%s'" % tenant_id
    )

def get_tenant_context() -> str | None:
    """Get current tenant context."""
    return _tenant_id_ctx.get()

**Middleware:**

# core/middleware.py
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

class TenantContextMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        # Extract tenant from header
        tenant_id = request.headers.get("X-Tenant-ID")

        if tenant_id:
            # Validate tenant exists
            tenant = db.query(Tenant).filter(
                Tenant.id == tenant_id
            ).first()

            if tenant:
                # Set tenant context for request
                set_tenant_context(tenant_id)

        response = await call_next(request)

        # Clear tenant context after request
        _tenant_id_ctx.set(None)

        return response

# Add to FastAPI app
app.add_middleware(TenantContextMiddleware)

---

Layer 5: Storage Isolation

S3 Prefix Isolation

Each tenant gets dedicated S3 prefix.

// src/lib/storage/s3.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
  }
});

export async function uploadFile(
  tenantId: string,
  file: File,
  filename: string
) {
  // ✅ CORRECT: Use tenant-specific prefix
  const key = `atom-saas/${tenantId}/uploads/${filename}`;

  // ❌ WRONG: No tenant isolation
  // const key = `uploads/${filename}`;

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET,
    Key: key,
    Body: file,
    ACL: 'private'  # Never use 'public'
  });

  await s3Client.send(command);
  return {
    url: `s3://${process.env.S3_BUCKET}/${key}`,
    key
  };
}

**S3 Bucket Policy:**

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::atom-saas/*",
      "Condition": {
        "StringNotLike": {
          "aws:userId": "tenant-*"
        }
      }
    }
  ]
}

Redis Namespace Isolation

// src/lib/cache/redis.ts
import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

export class TenantCache {
  constructor(private tenantId: string) {}

  private key(key: string): string {
    // ✅ CORRECT: Namespace by tenant
    return `tenant:${this.tenantId}:${key}`;
  }

  async get(key: string): Promise<string | null> {
    return redis.get(this.key(key));
  }

  async set(key: string, value: string, ttl?: number): Promise<void> {
    if (ttl) {
      await redis.setex(this.key(key), ttl, value);
    } else {
      await redis.set(this.key(key), value);
    }
  }

  async delete(key: string): Promise<void> {
    await redis.del(this.key(key));
  }

  // ❌ WRONG: No tenant namespace
  // async get(key: string): Promise<string | null> {
  //   return redis.get(key);
  // }
}

// Usage
const cache = new TenantCache(tenantId);
await cache.set('agent:123', 'cached-data', 300);

---

Common Patterns

Pattern 1: API Route with Tenant

// app/api/agents/route.ts
import { getTenantFromRequest } from '@/lib/tenant/tenant-service';
import { db } from '@/lib/database';

export async function GET(request: Request) {
  // 1. Extract tenant
  const tenant = await getTenantFromRequest(request);
  if (!tenant) {
    return NextResponse.json(
      { error: 'Tenant not found' },
      { status: 404 }
    );
  }

  // 2. Query with tenant filter
  const agents = await db.agents.findMany({
    where: {
      tenantId: tenant.id,
      status: 'active'
    },
    orderBy: {
      createdAt: 'desc'
    }
  });

  // 3. Return tenant-scoped results
  return NextResponse.json({
    success: true,
    data: { agents },
    meta: {
      tenant: {
        id: tenant.id,
        name: tenant.name
      }
    }
  });
}

export async function POST(request: Request) {
  // 1. Extract tenant
  const tenant = await getTenantFromRequest(request);
  if (!tenant) {
    return NextResponse.json(
      { error: 'Tenant not found' },
      { status: 404 }
    );
  }

  // 2. Parse request body
  const body = await request.json();

  // 3. Validate tenant limits
  const agentCount = await db.agents.count({
    where: { tenantId: tenant.id }
  });

  const tierLimits = {
    free: 3,
    solo: 10,
    team: 25,
    enterprise: -1  // unlimited
  };

  const limit = tierLimits[tenant.tier];
  if (limit !== -1 && agentCount >= limit) {
    return NextResponse.json(
      { error: 'Agent limit exceeded' },
      { status: 403 }
    );
  }

  // 4. Create with tenant_id
  const agent = await db.agents.create({
    data: {
      ...body,
      tenantId: tenant.id,
      createdBy: tenant.userId
    }
  });

  return NextResponse.json({
    success: true,
    data: { agent }
  });
}

Pattern 2: Backend Endpoint with Tenant

# api/routes/agent_routes.py
from fastapi import Depends, HTTPException, Header
from sqlalchemy.orm import Session
from core.database import get_db
from core.tenant import get_tenant_from_request

@router.get("/agents")
async def list_agents(
    tenant: Tenant = Depends(get_tenant_from_request),
    db: Session = Depends(get_db)
):
    """List all agents for current tenant."""
    agents = db.query(AgentRegistry).filter(
        AgentRegistry.tenant_id == tenant.id,
        AgentRegistry.is_deleted == False
    ).order_by(
        AgentRegistry.created_at.desc()
    ).all()

    return {
        "success": True,
        "data": {
            "agents": [agent.to_dict() for agent in agents]
        },
        "meta": {
            "tenant": {
                "id": tenant.id,
                "name": tenant.name
            }
        }
    }

@router.post("/agents")
async def create_agent(
    agent_data: AgentCreate,
    tenant: Tenant = Depends(get_tenant_from_request),
    db: Session = Depends(get_db)
):
    """Create new agent for current tenant."""

    # Check tenant limits
    agent_count = db.query(AgentRegistry).filter(
        AgentRegistry.tenant_id == tenant.id,
        AgentRegistry.is_deleted == False
    ).count()

    tier_limits = {
        "free": 3,
        "solo": 10,
        "team": 25,
        "enterprise": -1
    }

    limit = tier_limits.get(tenant.tier, 3)
    if limit != -1 and agent_count >= limit:
        raise HTTPException(
            status_code=403,
            detail=f"Agent limit exceeded for {tenant.tier} tier"
        )

    # Create agent with tenant_id
    agent = AgentRegistry(
        **agent_data.dict(),
        tenant_id=tenant.id,
        created_by=tenant.user_id
    )

    db.add(agent)
    db.commit()
    db.refresh(agent)

    return {
        "success": True,
        "data": {
            "agent": agent.to_dict()
        }
    }

Pattern 3: Background Jobs with Tenant

// lib/jobs/process-episodes.ts
import { db } from '@/lib/database';

export async function processEpisodes(tenantId: string) {
  // ✅ CORRECT: Tenant-scoped query
  const episodes = await db.episodes.findMany({
    where: {
      tenantId: tenantId,
      status: 'pending'
    }
  });

  for (const episode of episodes) {
    await processEpisode(episode, tenantId);
  }
}

// ❌ WRONG: No tenant filter - processes ALL episodes!
export async function processEpisodes() {
  const episodes = await db.episodes.findMany({
    where: {
      status: 'pending'
    }
  });

  for (const episode of episodes) {
    await processEpisode(episode);
  }
}

---

Testing Tenant Isolation

Unit Tests

// __tests__/tenant-isolation.test.ts
import { describe, it, expect } from 'vitest';
import { db } from '@/lib/database';

describe('Tenant Isolation', () => {
  it('should not leak agents across tenants', async () => {
    // Create agents for different tenants
    const agent1 = await db.agents.create({
      data: {
        name: 'Agent 1',
        tenantId: 'tenant-1',
      }
    });

    const agent2 = await db.agents.create({
      data: {
        name: 'Agent 2',
        tenantId: 'tenant-2',
      }
    });

    // Query as tenant-1
    const tenant1Agents = await db.agents.findMany({
      where: { tenantId: 'tenant-1' }
    });

    expect(tenant1Agents).toHaveLength(1);
    expect(tenant1Agents[0].id).toBe(agent1.id);

    // Query as tenant-2
    const tenant2Agents = await db.agents.findMany({
      where: { tenantId: 'tenant-2' }
    });

    expect(tenant2Agents).toHaveLength(1);
    expect(tenant2Agents[0].id).toBe(agent2.id);
  });

  it('should enforce RLS policies', async () => {
    // Set tenant context
    await db.execute(`
      SET LOCAL app.current_tenant_id = 'tenant-1'
    `);

    // This should only return tenant-1 agents
    const agents = await db.$queryRaw`
      SELECT * FROM agents
    `;

    agents.forEach(agent => {
      expect(agent.tenant_id).toBe('tenant-1');
    });
  });
});

Integration Tests

# tests/test_tenant_isolation.py
import pytest
from core.database import SessionLocal
from core.models import Tenant, AgentRegistry

def test_tenant_isolation():
    """Test that tenants cannot access each other's data."""
    db = SessionLocal()

    # Create two tenants
    tenant1 = Tenant(id="tenant-1", name="Tenant 1", tier="free")
    tenant2 = Tenant(id="tenant-2", name="Tenant 2", tier="free")
    db.add_all([tenant1, tenant2])
    db.commit()

    # Create agents for each tenant
    agent1 = AgentRegistry(
        id="agent-1",
        name="Agent 1",
        tenant_id="tenant-1"
    )
    agent2 = AgentRegistry(
        id="agent-2",
        name="Agent 2",
        tenant_id="tenant-2"
    )
    db.add_all([agent1, agent2])
    db.commit()

    # Set tenant context to tenant-1
    db.execute("SET LOCAL app.current_tenant_id = 'tenant-1'")

    # Query should only return tenant-1's agent
    agents = db.query(AgentRegistry).all()
    assert len(agents) == 1
    assert agents[0].id == "agent-1"
    assert agents[0].tenant_id == "tenant-1"

    db.close()

---

Security Checklist

Before deploying any code, verify:

  • [ ] All API routes extract tenant from request
  • [ ] All database queries filter by tenant_id
  • [ ] All S3 paths include tenant prefix
  • [ ] All Redis keys use tenant namespace
  • [ ] All background jobs scoped to tenant
  • [ ] All webhooks validate tenant context
  • [ ] RLS policies enabled on all tables
  • [ ] Tenant context set in middleware
  • [ ] Tests verify tenant isolation
  • [ ] No raw SQL without tenant filter
  • [ ] No SELECT * without WHERE tenant_id
  • [ ] Admin bypass routes properly secured

---

Common Mistakes

❌ Mistake 1: Forgetting Tenant Filter

// WRONG: Returns ALL agents across ALL tenants
const agents = await db.agents.findMany();

// CORRECT: Filter by tenant
const agents = await db.agents.findMany({
  where: { tenantId: tenant.id }
});

❌ Mistake 2: Subqueries Without Tenant

// WRONG: Subquery lacks tenant filter
const agents = await db.agents.findMany({
  where: {
    sessions: {
      some: {
        status: 'active'
        // Missing tenantId!
      }
    }
  }
});

// CORRECT: Include tenant in subquery
const agents = await db.agents.findMany({
  where: {
    tenantId: tenant.id,
    sessions: {
      some: {
        tenantId: tenant.id,
        status: 'active'
      }
    }
  }
});

❌ Mistake 3: Cache Without Namespace

// WRONG: Shared across all tenants
await redis.set('agent:123', data);

// CORRECT: Namespaced by tenant
await redis.set(`tenant:${tenantId}:agent:123`, data);

❌ Mistake 4: File Upload Without Tenant Path

// WRONG: Files from all tenants mixed together
const path = `/uploads/${filename}`;

// CORRECT: Tenant-isolated path
const path = `/${tenantId}/uploads/${filename}`;

---

References

  • **Implementation:** src/lib/tenant/tenant-service.ts
  • **Middleware:** middleware.ts
  • **RLS Migration:** backend-saas/alembic/versions/xxx_add_rls.py
  • **Tests:** __tests__/tenant-isolation.test.ts

---

**Last Updated:** 2025-02-06

**Status:** CRITICAL - READ AND UNDERSTAND